feat(objectql): publish the registry's three conflict error codes as importable constants - #16259
Conversation
…importable constants `NamespaceConflictError`, `ArtifactObjectNameConflictError` and `ObjectOwnershipConflictError` each already tell the reader to identify them by `code` rather than `instanceof`, and offered nothing to import. Convert the three inline literals to exported `*_CODE` constants on the shape the six existing constants in this package already use, and re-export them from the package barrel. The strings are byte-identical to the literals they replace: this moves where a spelling lives, never what it says. The three rows in `packages/runtime/src/dispatcher-error-vocabulary.ts` move from `shape: 'classfield'` to `shape: 'classconst'` — measured, not assumed: `check:dispatcher-error-vocabulary` reconciles in both directions and went red with 3 stale-row plus 3 unclassified-site findings until the rows were updated. The site census is unchanged at 66/66 classified either side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift Check10 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 33 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin be82d967f34581aad469890b158a8044f75f1bcd && git checkout be82d967f34581aad469890b158a8044f75f1bcd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 60c0f6134680c23cafb2f85185e64634ca3a4080 5ca78183503e186b61c7b5daa029a577ee52292a && git checkout -B drift-repro 60c0f6134680c23cafb2f85185e64634ca3a4080 && git merge --no-ff 5ca78183503e186b61c7b5daa029a577ee52292a
node scripts/docs-audit/affected-docs.mjs --json 60c0f6134680c23cafb2f85185e64634ca3a4080 |
Contract review (clause ②) — PASS on content · landable on green CI — PR #16259 at head
|
…table constant `content/docs/kernel/contracts/data-engine.mdx` already tells readers, of this exact refusal, to "Catch it by `code`, not `instanceof`, and read `drops` for the per-reason breakdown" — and the code was an inline string literal with nothing to import. The published guidance and the published surface disagreed, in the documentation's own words; that is why this row of #16159's table was converted ahead of the latent ones. `READONLY_FIELD_REJECTED_CODE` is a new export from `@objectstack/objectql`, re-exported from `index.ts` and deliberately not from the lean `core.ts` entry, matching the `*_CODE` constants already in this package. Dropping the `ERR_` prefix from the constant's NAME follows the two `ERR_`-prefixed precedents here (`HOOK_TARGET_REBIND_ERROR_CODE`, `SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE`). The string is byte-identical to the literal it replaces: the quoted spelling occurs exactly once in the file on both sides of the change — it moved, it did not multiply or mutate. Two gate readings, measured rather than assumed, and the second contradicts what was expected of it: - `check:error-code-provenance` is NOT neutral here. Unlike the codes converted on #16259, `ERR_READONLY_FIELD_REJECTED` IS in `ERROR_CODE_LEDGER` (the gate skips unregistered codes), so the new constant is a `constdef` stamp site the gate sees: 310 -> 311 sites, 294 -> 295 listed, waivers unchanged at 16, exit 0 both sides. It passes because the code is listed under this package's own owner key. - `check:dispatcher-error-vocabulary` does NOT move, and no row in `packages/runtime/src/dispatcher-error-vocabulary.ts` changes. That table records UNREGISTERED code sites (`return !registered.has(value)`), and this code is registered, so the site is invisible to it by construction — there is no row for it to move. 66/66 classified either side, exit 0. No cross-package edit is owed for this row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Part of #16159
The first slice of that card's sweep: the three rows in
packages/objectql/src/registry.tsonly —NAMESPACE_CONFLICT,DUPLICATE_ARTIFACT_OBJECT_NAME,OBJECT_OWNERSHIP_CONFLICT.Part of, notFixes: the card lists further rows in four other files, and this PR discharges three of them. It is deliberately not a batch conversion — each row is a published-surface widening, so each slice is its own Clause-② decision and its ownminorchangeset, exactly as the card argued.⛔ Out of scope and untouched: every other row, and in particular
validation/record-validator.ts. ItsVALIDATION_FAILEDversus the already-exportedEMPTY_CREDENTIAL_REFUSAL_CODE='VALIDATION_ERROR'is an unresolved naming question, and promoting either to a published constant would freeze that disagreement into the surface. #16159 remains open.What changed
Three inline string literals became exported
*_CODEconstants on the shape the six existing constants in this package already use, re-exported from theindex.tsbarrel:NAMESPACE_CONFLICT_CODENAMESPACE_CONFLICTDUPLICATE_ARTIFACT_OBJECT_NAME_CODEDUPLICATE_ARTIFACT_OBJECT_NAMEOBJECT_OWNERSHIP_CONFLICT_CODEOBJECT_OWNERSHIP_CONFLICT⛔ The strings did not change, and that is measured rather than asserted. Each constant holds text byte-identical to the literal it replaces, compared programmatically against the base blob. In the built artifacts each of the three wire strings appears exactly once in
dist/index.jsand once indist/index.mjs, on both sides of the change — the conversion did not add a second spelling to the bundle.The error classes stay unexported, deliberately. Publishing them would publish the
instanceofroute this convention exists to replace:@objectstack/objectqldeclares both realms in its ownexports, so a consumer holding the other realm's copy getsinstanceof=== false, silently (#14936). The constants are the affordance; the classes are not.Not on the lean
core.tsentry — measured: all six existing*_CODEconstants are onindex.tsonly, andcore.d.ts's exported-name count is unchanged at 79 either side. Whether a@objectstack/objectql/coreconsumer should also reach these is a real question, but it is true of all six existing constants too, so it belongs to the sweep and not to this PR.The one thing that was NOT neutral, and how it was found
The card's dispatch asked whether
check:error-code-provenancechanges what it counts. Measured both gates rather than assumed:check:error-code-provenanceis neutral by construction, not merely green.scanSourceTextdoesif (!registered.has(code)) continue;, and none of these three codes is in the ledger — 0 hits each inerror-code-ledger.zod.tsagainst firing controls ofERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED(2) andVALIDATION_ERROR(3) on the same command and file. A newconstdeffor an unregistered code cannot enter that population.check:dispatcher-error-vocabularywas NOT neutral, and went red. That gate reconciles in both directions and had all three rows declared asshape: 'classfield'. Converting the literal to a constant moves the site to theclassconstrecogniser, so the gate returned exit 1 with six findings — threestale-rowplus threeunclassified-site— until the three rows inpackages/runtime/src/dispatcher-error-vocabulary.tswere updated toshape: 'classconst'. Baseline and final both read 66 unregistered code-stamping sites, 66 classified: the census is unchanged, only the shape a site is matched by moved. Each row'sdoorandverdictare untouched.That runtime file is not exported from
@objectstack/runtime's barrel, and its two published declarations are byte-identical hashes across the ablation (152 exported names either side) — so runtime publishes nothing new and owes no changeset entry. Measured, not inferred.Clause-② —
yes, measuredBuilt at head, swapped the three sources back to the base commit
a3bbb8b1a, rebuilt, and compared every declaration file each package publishes (files: ["dist", …]), not the root barrel alone — including the content-hashed shared chunk, which is where a previous round's real surface change hid. The rebuild is proven to have re-run (dist/index.jsmtimes moved on both packages).Comparing exported name sets rather than bytes, because the byte diff is dominated by tsup's alias renumbering and a changed chunk filename, neither of which is a surface change:
objectql/dist/index.d.tsobjectql/dist/core.d.tsobjectqlshared chunkruntime/dist/index.d.tsruntime/dist/index.d.ctsAdditive widening of a published surface, nothing removed ⇒ Clause-②
yesand aminorchangeset.needs:contract-reviewhung on both carriers by the PR creator.Tests
New
packages/objectql/src/registry-conflict-code-constants.test.ts— six cases. Each refusal is asserted on itscodeandstatusenvelope, never a baretoThrow(): #14367 measured on this very path that a throw-shaped assertion stayed green with the check one layer up ablated, because a second refusal fired one step later and was indistinguishable.The existing rejection tests keep spelling the wire strings as literals on purpose — the test layer is outside
check:error-code-provenance's scanned population, so those literals cost no stamp site while acting as an independent pin that a silent rename of a constant cannot pass.Two ablations, each committed first, each proving the mutation on disk before measuring and the restore byte-exact after:
NAMESPACE_CONFLICT_CODE's value turns exactly 2 of 6 cases red — the wire-string pin and the cross-realm control. The four that compare against the constant stay green, which is precisely why case 1 spells the literal.Why the sweep should continue
Worth recording, and not repaired here because it is a different row:
content/docs/kernel/contracts/data-engine.mdx:361tells readers "Catch it bycode, notinstanceof" aboutReadonlyFieldRejectedError— whose code is still an inline literal atreadonly-strict-errors.ts:161. Confirmed still true on today'smain. The published guidance and the published surface disagree today, in the documentation's own words, and that row is the strongest argument for finishing the sweep.Also re-measured for these three specifically: none of the three codes or constants appears anywhere in
content/docs(0 files each, against firing controls of@objectstack/objectqlon 12 files andReadonlyFieldRejectedErroron 1). No page enumerates them; none goes stale by adding one.Census reconciliation
The card's table and triage's independent re-count (comment
5556857711) disagreed on one row, and triage was right when it was written and is now stale: it recordedsystem-write-organization.tsas still an inline literal, making 12 rather than 11. On today'smain#16156 has landed (48b0fcf24is an ancestor) and that site readsSYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE. So the live count onmainbefore this PR is 10 inline literals, and this PR takes 3 of them, leaving 7. Triage's note ① also holds:readonly code =finds 16, andnav-contribution-diagnostics.ts:93is the 17th carrier under atypeofspelling.⛔ The card's line numbers are stale for two of the three rows (
:1404/:1463are now:1406/:1467); everything here was located by text.The template
This is the first slice, so the shape is meant to be copied rather than admired: one shared docblock carrying the "by
code, notinstanceof" reasoning once, three one-line constants beside the classes they belong to, one barrel re-export block, one pin test, oneminorchangeset — and a check of whether the row has adispatcher-error-vocabularydeclaration whoseshapemust move with it. That last step is the one a mechanical repeat of this conversion will miss.Verification
Everything below ran against
5ca781835, the commit this PR carries, on a clean tree.pnpm --filter @objectstack/objectql exec vitest run— 276 files / 4755 tests passedpnpm --filter @objectstack/runtime exec vitest run— 234 files / 3318 tests passedpnpm --filter @objectstack/objectql --filter @objectstack/runtime typecheck— green, test layer included; the new test file is proven insidetsconfig.test.json's program via--listFiles, contributing 0 of the 242 pre-existing debt errorsscripts/pm/dispatch-gates.mjs --commands, pluscheck:error-code-provenance,check:error-code-casing,check:error-status-conformance,check:authz-resolver,check:filter-alias-parity— all exit 0, captured with redirect-then-read, never through a pipepnpm lint(repo-wideeslint . --no-inline-config) — exit 0, run in full rather than narrowedcheck:dual-build-cjs-loadsandcheck:type-check-debtfirst returned exit 3, PREREQUISITE NOT MET — reported here because that is not a pass. The full workspace closure was then built aslint.ymldoes and both re-run to a real exit 0;type-check-debtre-measured 12 ledger entries, 140 raw errors, none above its recorded number.packages/objectql/src/and touchesindex.tsat line ~144. This PR's re-export sits in the registry block at lines 68–85, so the two do not overlap textually. This branch was not rebased onto it and none of its hunks were touched; if a conflict appears at merge time it should go back to the respective claims rather than be resolved here.🤖 Generated with Claude Code
https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Generated by Claude Code